經過上一篇文章我們了解到在微服務的架構下,統一管理請求基本信息的重要性,在 API Gateway 提取請求信息並妥善管理可以大大降低業務邏輯實作的複雜度,同時利於微服務間對同一個請求信息的傳遞與追蹤,今天我們就要來實作看看,如何在 API Gateway 使用 Filter 在過濾請求時提取請求的基本信息,並整合到既有的 API 限流邏輯中,目的在於實現更細粒度的限流控制,如用戶級別、IP 級別的請求,而不單單只是全域的限流。
首先,為什麼要在 Filter 提取請求上下文的基本信息,這是我剛開始接觸到這個概念、開始實作前的第一個疑問,同樣是在到 Controller 之前攔截請求,為什麼不用 Interceptor 或 AOP 呢?後來我得知這是 Filter 跟 Interceptor 之間執行時機及作用範圍的差異。上述三者的執行順序為:
Filter → Interceptor → AOP → (Controller)
可以說 Filter 是 Http 請求進到後端系統會遇到的第一個組件,Filter 它是 Servlet 規範的一部分,由 Web 容器來管理,所以當一個請求進來還沒交給 Spring 的 DispatcherServlet 前,就會先經過 Filter。所以重點是 Filter 可以攔截「所有」的請求,但 Interceptor 卻只能攔截 DispatcherServlet 派往 Controller 的請求,所以如果說為何選擇在 Filter 而不是 Interceptor 提取請求上下文,以下是原因:
至於如何定義 Filter Chain 的順序、如何定義 Filter 要過濾的路徑等細節就不在此展開了。
我們等等除了要創建 RequestContextFilter 外,還有另外兩個在 Filter 裡會用到的 class:
@Getter
@Setter
public class RequestInfo {
private String requestId;
private String ipAddress;
private Map<String, String> headers;
}
RequestContextHelper 的主要用途是管理請求上下文信息,透過 ThreadLocal 機制為每個請求提供以下功能:
@Component
public class RequestContextHelper {
private static final ThreadLocal<RequestInfo> REQ_CONTEXT = new ThreadLocal<>();
public static void setRequestInfo(RequestInfo requestInfo) {
REQ_CONTEXT.set(requestInfo);
}
public static String getClientIp() {
var info = REQ_CONTEXT.get();
return info != null ? info.getIpAddress() : null;
}
public static void clear() {
REQ_CONTEXT.remove();
}
}
接著透過上述兩個 class 實作 RequestContextFilter ,攔截進入系統的 Http 請求,從中提取和管理請求的上下文信息:
@Component
public class RequestContextFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
try {
var requestInfo = new RequestInfo();
requestInfo.setRequestId(UUID.randomUUID().toString().replace("-", ""));
requestInfo.setIpAddress(getClientIp(httpRequest));
var headers = new HashMap<String, String>();
headers.put("X-Trace-ID", requestInfo.getRequestId());
headers.put("X-Client-IP", requestInfo.getIpAddress());
requestInfo.setHeaders(headers);
RequestContextHelper.setRequestInfo(requestInfo);
chain.doFilter(request, response);
} finally {
RequestContextHelper.clear();
}
}
private String getClientIp(HttpServletRequest request) {
var xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
return xForwardedFor.split(",")[0].trim();
}
var xRealIp = request.getHeader("X-Real-IP");
if (xRealIp != null && !xRealIp.isEmpty()) {
return xRealIp;
}
return request.getRemoteAddr();
}
}
在 Filter 搜集好 RequestContext 後,到了 AOP 切面我們就有更多請求的信息可以隨手使用了,有了用戶 IP 我們可以為限流器做更細粒度的控制,例如同一個 IP 在多久時間內不能訪問某某 API 幾次,把從 RequestContext 中提取出來的 IP 融合進 key 的生成策略就能輕易的做到這點:
boolean byClientIP() default false;
參數:@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimiter {
Algorithm algorithm() default Algorithm.FIXED_WINDOW;
String key() default "rateLimiter";
int limit() default 10;
int window() default 60;
boolean byClientIP() default false; // 這邊加上一個新的參數
String fallback() default "";
Class<?> fallbackClass() default Void.class;
}
@Component
public class RateLimiterKeyGenerator {
private static final String KEY_SEPARATOR = ":";
public String generateKey(ProceedingJoinPoint joinPoint, RateLimiter rateLimiter) {
var baseKey = getBaseKey(joinPoint);
var contextKey = getContextKey(rateLimiter);
return String.join(KEY_SEPARATOR, baseKey, contextKey);
}
private String getBaseKey(ProceedingJoinPoint joinPoint) {
var className = joinPoint.getTarget().getClass().getSimpleName();
var methodName = joinPoint.getSignature().getName();
return String.join(KEY_SEPARATOR, className, methodName);
}
private String getContextKey(RateLimiter rateLimiter) {
var keyParts = new StringBuilder();
if (rateLimiter.byClientIP()) {
var clientIp = RequestContextHelper.getClientIp();
keyParts.append("ip:").append(clientIp != null ? clientIp : "unknown");
}
return keyParts.toString();
}
}
throw new BaseException(StatusCode.TOO_MANY_REQUEST,
String.format("Rate limit exceeded by key: %s. Max %d requests per %d seconds",
key, rateLimiter.limit(), rateLimiter.window()));
測試超過限流次數就會發現這次請求控制的粒度更精細到了用戶 IP 的層級:
key: RateLimiterTestController:getRequestContextString:ip:0:0:0:0:0:0:0:1
{
"error": "too_many_request",
"message": "Rate limit exceeded by key: RateLimiterTestController:getRequestContextString:ip:0:0:0:0:0:0:0:1. Max 5 requests per 10 seconds"
}
今天提到了為何選擇在請求到達 Filter 時做 RequestContext 基本信息的提取,是因為它是請求進到後端第一個路過的組件,我們提到了請求從進到後端一直到執行完業務邏輯的順序,提到了 Filter 跟 Interceptor 兩者執行時機與作用範圍的差別,隨後開始實作了 Filter 的具體細節,規劃了用來儲存基本信息的 RequestInfo 資料結構,還有儲存 RequestInfo 的 ThreadLocal 物件,其目的在於為每個請求分配一個獨立的線程做隔離,並且在其後都能在請求生命週期期間做提取,接下來又將 RateLimiter 的鏈路做優化,以整合進 RequestContext 的基本信息,為請求限流做更為精細的控制。
接下來幾天會開始將現有單體架構逐步擴展成分布式環境下可以兼容的系統,會從 Redis 的配置做開頭,可能會把文章的篇幅拆得小一點,但同時盡力確保完整度,目的是希望可以細水長流,持續產出文章。